Conversation
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. WalkthroughActivityDetailForm 클라이언트를 제거하고, 서버 측 비동기 페이지에서 활동 데이터를 직접 페치하도록 재구성했으며 예약 로직을 BookingSection으로 분리하고 이미지 최적화와 캐시 무효화 지점을 추가했습니다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Browser as Client (page)
participant Store as User Store
participant API as Public Activity API
participant PrivateAPI as Private Schedule API
User->>Browser: 활동 상세 페이지 요청 (id)
Browser->>API: 활동 데이터 fetch(id)
API-->>Browser: 활동 데이터 (또는 404)
alt 404
Browser->>Browser: notFound()
else 성공
Browser->>Store: 현재 사용자 ID 조회
Store-->>Browser: currentUserId
Browser->>Browser: 소유권 계산 (userId === currentUserId)
alt 소유자 아님
Browser->>PrivateAPI: 월별 스케줄 요청 (현재/이전/다음)
PrivateAPI-->>Browser: 스케줄 응답들
Browser->>Browser: 스케줄 병합 및 BookingSection 렌더
else 소유자
Browser->>Browser: BookingSection 미렌더링
end
Browser->>Browser: ImageGrid, Title, LocationMap, ReviewSection 렌더링
Browser-->>User: 페이지 표시
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/DatePicker/DatePicker.tsx (1)
55-65:⚠️ Potential issue | 🟠 Major디버그용
console.log제거 필요프로덕션 코드에 디버그 로그가 남아 있습니다.
- Lines 55–65:
useEffect전체 본문이console.log호출만으로 구성되어 실질적인 사이드 이펙트가 없으며,highlightDates도 의존성 배열에서 누락되어 있습니다 (react-hooks/exhaustive-deps위반). 이useEffect자체를 제거해야 합니다.- Line 81: 컴포넌트 바디 최상위 레벨에 위치한
console.log로, 매 렌더마다 실행되어 성능에 영향을 주고 내부 상태를 노출합니다.🔧 제안하는 수정
- useEffect(() => { - console.log( - '가능날짜', - availableDates.map((d) => d.date), - ); - console.log( - '하이라이트날짜', - highlightDates.map((d) => d.format('YYYY-MM-DD')), - ); - console.log('뷰데이트', viewDate.format('YYYY-MM-DD')); - }, [availableDates, viewDate]); const isPrevDisabled = ... const changeMonth = ... - console.log('schedules', schedules); const handleDateSelect = ...Also applies to: 81-81
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/DatePicker/DatePicker.tsx` around lines 55 - 65, Remove the debug console.logs: delete the entire useEffect that only logs availableDates, highlightDates, and viewDate (the useEffect referencing availableDates and viewDate) and remove the top-level console.log in the component body; ensure you do not rely on that effect for any side effects and that any real effects include all needed deps (e.g., add highlightDates to deps if you later reintroduce an effect) — search for the useEffect containing availableDates.map and highlightDates.map and the standalone console.log at the component's top level to locate and remove them.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx:
- Around line 61-66: Remove the unnecessary setTimeout wrapper in
handleMonthChange inside BookingSection.tsx: call setYear(year) and
setMonth(month) directly (no delayed callback) since React 18+ batches state
updates; keep the useCallback for handleMonthChange but you can leave the
dependency array empty (setState setters are stable) or include setYear/setMonth
if you prefer explicit deps.
- Around line 50-54: The filter uses PromiseFulfilledResult<any> which triggers
no-explicit-any; replace the any with the actual fulfilled value type by
declaring or reusing the correct type for the responses (e.g., SideItem or the
API response type) and update the type guard on sideResults to (r): r is
PromiseFulfilledResult<TheRealType> => r.status === 'fulfilled' so flatMap((r)
=> r.value.data) has a concrete type; locate the sideResults variable and the
data shape used in BookingSection.tsx to pick the exact type (or import an
existing interface) and use that type in the PromiseFulfilledResult generic.
In `@src/app/`(with-header)/activities/[id]/components/Title.tsx:
- Around line 27-30: Extract the duplicated owner-check logic (currently using
useUserStore and computing currentUserId and isOwner in Title.tsx,
BookingSection.tsx, and ActivityDetailSkeleton.tsx) into a small reusable hook
named useIsOwner(userId) that returns a boolean; implement useIsOwner to read
currentUserId from useUserStore (same selector used today) and return
currentUserId != null && currentUserId === userId, then replace the inline logic
in Title.tsx, BookingSection.tsx, and ActivityDetailSkeleton.tsx to call
useIsOwner(userId) instead of duplicating the selector and comparison.
In `@src/app/`(with-header)/activities/[id]/loading.tsx:
- Around line 4-72: Extract the duplicated skeleton markup into a single shared
presentational component (e.g., ActivityDetailSkeleton or
SharedActivitySkeleton) and have both the server Loading() and the existing
ActivityDetailSkeleton.tsx consume it; move purely UI pieces (the grid,
placeholders, and repeated ReviewCardSkeleton usage) into the new shared
component and keep client-only bits like SkeletonBookingInterface or
ReviewCardSkeleton usage either inside a small client wrapper component or
passed as slots/props so Loading (a server component) can render the shared
layout without importing client-only modules directly; update Loading and
src/app/.../ActivityDetailSkeleton.tsx to import and render the new shared
component, preserving keys like ReviewCardSkeleton and SkeletonBookingInterface
by wrapping them in a client component if needed.
- Line 6: The outer container div that currently has the class 'animate-pulse'
is causing nested animation with the child skeleton components
(ReviewCardSkeleton and SkeletonBookingInterface); remove the 'animate-pulse'
class from the outer div (the div with className 'mx-auto max-w-1200 ...') so
that only the inner components control their own pulse animation, ensuring no
overlapping/duplicated animation occurs and keeping ReviewCardSkeleton and
SkeletonBookingInterface as the sole owners of their skeleton animation.
In `@src/app/`(with-header)/activities/[id]/page.tsx:
- Around line 54-78: Create a small client-side wrapper component (e.g.,
ActivityDetailClientLayout or BookingSectionWrapper) that determines current
user ownership and renders the outer grid with md:grid-cols-2 when the viewer is
the owner and md:grid-cols-3 otherwise; move the markup currently in page.tsx
(the div with mt-86 grid ... and its children including BookingSection,
LocationMap, ReviewSection) into that wrapper and use BookingSection (which may
return null) inside it so the wrapper can adjust the grid class based on
isOwner. Reference BookingSection and ActivityDetailSkeleton behavior as a model
for switching grid-cols depending on ownership; keep BookingSection
implementation unchanged and ensure page.tsx imports and uses the new client
wrapper instead of the static md:grid-cols-3 div.
- Around line 30-33: Change the catch parameter from any to unknown and add a
type guard (e.g., isNotFoundError(error: unknown): error is Error & { digest:
string }) that checks error instanceof Error and that error.digest ===
'NEXT_NOT_FOUND'; in the catch block use that guard to rethrow the
NEXT_NOT_FOUND error and otherwise throw the existing new Error('활동 상세 데이터 조회
실패') so you avoid using the any type while preserving the current behavior.
In `@src/app/`(with-header)/myactivity/hooks/useCreateActivityForm.ts:
- Around line 39-67: The current flow uploads bannerImageUrl and subImageUrls
via uploadImage before calling privateInstance.post('/addActivity'), which can
leave orphaned uploaded files if the POST fails; wrap the upload + POST sequence
in a try/catch and on any failure delete any already-uploaded images: track
uploaded URLs in bannerImageUrl and subImageUrls, and in the catch call your
storage deletion helper (e.g., deleteImage or an API endpoint you add) for each
uploaded URL, then rethrow or surface the error; update useCreateActivityForm to
perform uploads, attempt privateInstance.post('/addActivity', payload) inside
the same try, and cleanup uploaded images on catch to avoid orphaned resources.
- Around line 47-54: Replace the sequential for...of+await loop that uploads
each entry in subImage with a parallel Promise.all approach: map subImage to an
array of promises where each element returns uploadImage(img) if img is a File
or returns the img string otherwise, then await Promise.all on that array and
assign the resulting array to subImageUrls (or push the results) so uploads
happen concurrently; locate the logic in useCreateActivityForm where subImage,
uploadImage, and subImageUrls are referenced.
In `@src/app/api/editActivity/`[id]/route.ts:
- Line 40: The call to revalidateTag in the Route Handler uses the deprecated
single-argument form; update the invocation of revalidateTag(`activity-${id}`)
to the recommended two-argument form revalidateTag(`activity-${id}`, 'max') so
TypeScript errors are resolved and the tag is revalidated using SWR semantics;
locate the usage of revalidateTag in route.ts and change the call accordingly.
In `@src/app/api/reservations/`[id]/reviews/route.ts:
- Around line 42-44: Remove the unnecessary conditional guard and always call
revalidateTag using the required activityId from the ReviewResponse;
specifically, in the route handler replace the if-check around revalidateTag and
directly invoke revalidateTag(`activity-${response.data.activityId}`), relying
on the ReviewResponse.type (activityId: number) and the existing single-argument
revalidateTag usage.
---
Outside diff comments:
In `@src/components/DatePicker/DatePicker.tsx`:
- Around line 55-65: Remove the debug console.logs: delete the entire useEffect
that only logs availableDates, highlightDates, and viewDate (the useEffect
referencing availableDates and viewDate) and remove the top-level console.log in
the component body; ensure you do not rely on that effect for any side effects
and that any real effects include all needed deps (e.g., add highlightDates to
deps if you later reintroduce an effect) — search for the useEffect containing
availableDates.map and highlightDates.map and the standalone console.log at the
component's top level to locate and remove them.
| const handleMonthChange = useCallback((year: number, month: number) => { | ||
| setTimeout(() => { | ||
| setYear(year); | ||
| setMonth(month); | ||
| }); | ||
| }, []); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
setTimeout 래핑이 불필요할 수 있습니다.
React 18+에서는 상태 업데이트가 자동으로 배치 처리되므로, setTimeout으로 감싸지 않아도 setYear와 setMonth가 하나의 리렌더링으로 처리됩니다. 특별한 이유가 없다면 제거를 고려해 보세요.
♻️ 수정 제안
const handleMonthChange = useCallback((year: number, month: number) => {
- setTimeout(() => {
- setYear(year);
- setMonth(month);
- });
+ setYear(year);
+ setMonth(month);
}, []);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleMonthChange = useCallback((year: number, month: number) => { | |
| setTimeout(() => { | |
| setYear(year); | |
| setMonth(month); | |
| }); | |
| }, []); | |
| const handleMonthChange = useCallback((year: number, month: number) => { | |
| setYear(year); | |
| setMonth(month); | |
| }, []); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx around
lines 61 - 66, Remove the unnecessary setTimeout wrapper in handleMonthChange
inside BookingSection.tsx: call setYear(year) and setMonth(month) directly (no
delayed callback) since React 18+ batches state updates; keep the useCallback
for handleMonthChange but you can leave the dependency array empty (setState
setters are stable) or include setYear/setMonth if you prefer explicit deps.
| const currentUserId = useUserStore((state) => | ||
| state.user ? state.user.id : null, | ||
| ); | ||
| const isOwner = currentUserId != null && userId != null && currentUserId === userId; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
소유자 판별 로직이 여러 컴포넌트에 중복되어 있습니다.
Title.tsx, BookingSection.tsx, ActivityDetailSkeleton.tsx에서 동일한 useUserStore + isOwner 계산 패턴이 반복됩니다. 간단한 커스텀 훅으로 추출하면 일관성과 유지보수성을 높일 수 있습니다.
♻️ 리팩토링 제안
// hooks/useIsOwner.ts
import useUserStore from '@/stores/authStore';
export function useIsOwner(userId: number): boolean {
const currentUserId = useUserStore((state) =>
state.user ? state.user.id : null,
);
return currentUserId != null && currentUserId === userId;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/components/Title.tsx around lines 27 -
30, Extract the duplicated owner-check logic (currently using useUserStore and
computing currentUserId and isOwner in Title.tsx, BookingSection.tsx, and
ActivityDetailSkeleton.tsx) into a small reusable hook named useIsOwner(userId)
that returns a boolean; implement useIsOwner to read currentUserId from
useUserStore (same selector used today) and return currentUserId != null &&
currentUserId === userId, then replace the inline logic in Title.tsx,
BookingSection.tsx, and ActivityDetailSkeleton.tsx to call useIsOwner(userId)
instead of duplicating the selector and comparison.
| export default function Loading() { | ||
| return ( | ||
| <div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'> | ||
| {/* 타이틀 */} | ||
| <div className='mb-6 flex items-start justify-between'> | ||
| <div className='flex w-full flex-col gap-10'> | ||
| <div className='h-16 w-24 rounded bg-gray-300' /> | ||
| <div className='h-42 w-3/4 rounded bg-gray-300' /> | ||
| <div className='flex gap-10'> | ||
| <div className='h-20 w-50 rounded bg-gray-300' /> | ||
| <div className='h-20 w-170 rounded bg-gray-300' /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* 이미지그리드 */} | ||
| <div className='relative block aspect-square h-[300px] w-full overflow-hidden rounded-lg bg-gray-300 md:hidden' /> | ||
| <div className='hidden h-[500px] grid-cols-4 grid-rows-4 gap-6 md:grid'> | ||
| <div className='col-span-2 row-span-4 rounded-lg bg-gray-300' /> | ||
| {[...Array(4)].map((_, i) => ( | ||
| <div | ||
| key={i} | ||
| className='col-span-1 row-span-2 rounded-lg bg-gray-300' | ||
| /> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* 설명/예약인터페이스/장소 */} | ||
| <div className='mt-86 grid gap-10 grid-cols-1 md:grid-cols-3'> | ||
| {/* 설명 */} | ||
| <div className='md:col-span-2'> | ||
| <div className='mb-10 h-34 w-90 rounded bg-gray-300' /> | ||
| <div className='mb-4 h-180 w-full rounded bg-gray-300' /> | ||
| </div> | ||
|
|
||
| {/* 예약인터페이스 */} | ||
| <div className='md:row-span-2'> | ||
| <SkeletonBookingInterface /> | ||
| </div> | ||
|
|
||
| {/* 체험 장소/리뷰 */} | ||
| <div className='md:col-span-2 space-y-8'> | ||
| {/* 장소 */} | ||
| <div className='mb-40'> | ||
| <div className='mb-10 h-34 w-90 rounded bg-gray-300' /> | ||
| <div className='h-[480px] w-full rounded-lg bg-gray-400 shadow-md' /> | ||
| <div className='mt-8 flex items-center space-x-3'> | ||
| <div className='h-6 w-6 rounded-full bg-gray-300' /> | ||
| <div className='h-20 w-1/2 rounded bg-gray-300' /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* 리뷰 */} | ||
| <div> | ||
| <div className='mt-10 flex flex-col space-y-8'> | ||
| <div className='mb-10 h-34 w-50 rounded bg-gray-300' /> | ||
| <div className='mb-5 h-50 w-120 rounded bg-gray-300' /> | ||
| <div className='relative min-h-450 flex-col gap-30'> | ||
| {[...Array(3)].map((_, index) => ( | ||
| <ReviewCardSkeleton key={index} /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
ActivityDetailSkeleton과의 코드 중복을 고려해 보세요.
src/app/(with-header)/activities/[id]/components/Skeletons/ActivityDetailSkeleton.tsx에 거의 동일한 스켈레톤 마크업이 존재합니다. loading.tsx는 서버 컴포넌트라 소유자 판별이 불가하지만, 공통 레이아웃 부분을 공유 컴포넌트로 추출하면 유지보수성을 높일 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/loading.tsx around lines 4 - 72,
Extract the duplicated skeleton markup into a single shared presentational
component (e.g., ActivityDetailSkeleton or SharedActivitySkeleton) and have both
the server Loading() and the existing ActivityDetailSkeleton.tsx consume it;
move purely UI pieces (the grid, placeholders, and repeated ReviewCardSkeleton
usage) into the new shared component and keep client-only bits like
SkeletonBookingInterface or ReviewCardSkeleton usage either inside a small
client wrapper component or passed as slots/props so Loading (a server
component) can render the shared layout without importing client-only modules
directly; update Loading and src/app/.../ActivityDetailSkeleton.tsx to import
and render the new shared component, preserving keys like ReviewCardSkeleton and
SkeletonBookingInterface by wrapping them in a client component if needed.
|
|
||
| export default function Loading() { | ||
| return ( | ||
| <div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'> |
There was a problem hiding this comment.
외부 컨테이너의 animate-pulse가 내부 자식 요소의 animate-pulse와 중첩됩니다.
Line 6의 외부 div에 animate-pulse가 적용되어 있지만, ReviewCardSkeleton과 SkeletonBookingInterface 내부 요소들에도 각각 animate-pulse가 개별적으로 적용되어 있습니다. 이로 인해 애니메이션이 중첩되어 의도치 않은 시각적 깜빡임이 발생할 수 있습니다.
외부 animate-pulse를 제거하거나, 내부 컴포넌트의 개별 animate-pulse를 제거하여 한 레벨에서만 적용하는 것을 권장합니다.
🛠️ 수정 제안
- <div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'>
+ <div className='mx-auto max-w-1200 p-4 sm:px-20 lg:p-8'>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'> | |
| <div className='mx-auto max-w-1200 p-4 sm:px-20 lg:p-8'> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/loading.tsx at line 6, The outer
container div that currently has the class 'animate-pulse' is causing nested
animation with the child skeleton components (ReviewCardSkeleton and
SkeletonBookingInterface); remove the 'animate-pulse' class from the outer div
(the div with className 'mx-auto max-w-1200 ...') so that only the inner
components control their own pulse animation, ensuring no overlapping/duplicated
animation occurs and keeping ReviewCardSkeleton and SkeletonBookingInterface as
the sole owners of their skeleton animation.
| <div className='mt-86 grid gap-15 grid-cols-1 md:grid-cols-3'> | ||
| <div className='md:col-span-2'> | ||
| <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 설명</h2> | ||
| <p className='leading-relaxed whitespace-pre-line'> | ||
| {activityData.description} | ||
| </p> | ||
| </div> | ||
|
|
||
| <BookingSection | ||
| activityId={id} | ||
| userId={activityData.userId} | ||
| price={activityData.price} | ||
| /> | ||
|
|
||
| <div className='md:col-span-2'> | ||
| <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 장소</h2> | ||
| <LocationMap address={activityData.address ?? ''} /> | ||
|
|
||
| <ReviewSection | ||
| activityId={Number(id)} | ||
| reviewCount={activityData.reviewCount} | ||
| rating={activityData.rating} | ||
| /> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
소유자(Owner)일 때 레이아웃이 3열 그리드에서 비어 보일 수 있습니다.
BookingSection이 소유자에게 null을 반환하지만, 그리드는 항상 md:grid-cols-3으로 설정되어 있습니다. 소유자의 경우 예약 인터페이스가 없으므로 불필요한 여백이 생길 수 있습니다. ActivityDetailSkeleton에서는 소유자 여부에 따라 grid-cols-2/grid-cols-3을 동적으로 전환하고 있습니다.
서버 컴포넌트에서는 클라이언트 상태(현재 사용자)에 접근할 수 없으므로, 이 레이아웃 조정을 BookingSection 내부의 wrapper로 처리하거나, 별도의 클라이언트 레이아웃 컴포넌트로 분리하는 것을 고려해 보세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/page.tsx around lines 54 - 78, Create
a small client-side wrapper component (e.g., ActivityDetailClientLayout or
BookingSectionWrapper) that determines current user ownership and renders the
outer grid with md:grid-cols-2 when the viewer is the owner and md:grid-cols-3
otherwise; move the markup currently in page.tsx (the div with mt-86 grid ...
and its children including BookingSection, LocationMap, ReviewSection) into that
wrapper and use BookingSection (which may return null) inside it so the wrapper
can adjust the grid class based on isOwner. Reference BookingSection and
ActivityDetailSkeleton behavior as a model for switching grid-cols depending on
ownership; keep BookingSection implementation unchanged and ensure page.tsx
imports and uses the new client wrapper instead of the static md:grid-cols-3
div.
| let bannerImageUrl = ''; | ||
| if (typeof mainImage === 'string') { | ||
| bannerImageUrl = mainImage; | ||
| } else if (mainImage instanceof File) { | ||
| bannerImageUrl = await uploadImage(mainImage); | ||
| } | ||
|
|
||
| const subImageUrls: string[] = []; | ||
| for (const img of subImage) { | ||
| if (img instanceof File) { | ||
| const url = await uploadImage(img); | ||
| subImageUrls.push(url); | ||
| } else if (typeof img === 'string') { | ||
| subImageUrls.push(img); | ||
| } | ||
| } | ||
|
|
||
| const payload = { | ||
| title, | ||
| category, | ||
| description, | ||
| address, | ||
| price: parsedPrice, | ||
| schedules: dates, | ||
| bannerImageUrl: mainImage, | ||
| subImageUrls: subImage, | ||
| bannerImageUrl, | ||
| subImageUrls, | ||
| }; | ||
|
|
||
| const res = await privateInstance.post('/addActivity', payload); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
이미지 업로드 후 체험 생성 API 실패 시 고아(orphaned) 리소스 발생 가능
bannerImageUrl·subImageUrls 업로드가 완료된 뒤 /addActivity POST가 실패하면 이미 스토리지에 올라간 이미지 파일들이 고아 상태로 남습니다. 등록 수가 많아질수록 불필요한 스토리지 비용이 누적됩니다. 현재 아키텍처의 구조적 한계이지만, 향후 다음 중 하나를 고려할 수 있습니다.
- 서버 측 처리: 이미지 업로드와 체험 생성을 하나의 트랜잭션 또는 서버 액션으로 묶어 원자성 보장
- 주기적 정리: 미사용 이미지를 정리하는 배치 작업 또는 TTL 정책 추가
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/myactivity/hooks/useCreateActivityForm.ts around lines
39 - 67, The current flow uploads bannerImageUrl and subImageUrls via
uploadImage before calling privateInstance.post('/addActivity'), which can leave
orphaned uploaded files if the POST fails; wrap the upload + POST sequence in a
try/catch and on any failure delete any already-uploaded images: track uploaded
URLs in bannerImageUrl and subImageUrls, and in the catch call your storage
deletion helper (e.g., deleteImage or an API endpoint you add) for each uploaded
URL, then rethrow or surface the error; update useCreateActivityForm to perform
uploads, attempt privateInstance.post('/addActivity', payload) inside the same
try, and cleanup uploaded images on catch to avoid orphaned resources.
| for (const img of subImage) { | ||
| if (img instanceof File) { | ||
| const url = await uploadImage(img); | ||
| subImageUrls.push(url); | ||
| } else if (typeof img === 'string') { | ||
| subImageUrls.push(img); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
서브 이미지 업로드를 병렬로 처리하세요.
for...of + await 패턴은 서브 이미지를 순차적으로 업로드하여 최대 4개 파일을 직렬 처리합니다. Promise.all로 병렬 처리하면 전체 업로드 시간을 단축할 수 있습니다.
⚡ 병렬 업로드로 리팩토링 제안
- const subImageUrls: string[] = [];
- for (const img of subImage) {
- if (img instanceof File) {
- const url = await uploadImage(img);
- subImageUrls.push(url);
- } else if (typeof img === 'string') {
- subImageUrls.push(img);
- }
- }
+ const subImageUrls = await Promise.all(
+ subImage.map((img) =>
+ img instanceof File ? uploadImage(img) : Promise.resolve(img as string),
+ ),
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for (const img of subImage) { | |
| if (img instanceof File) { | |
| const url = await uploadImage(img); | |
| subImageUrls.push(url); | |
| } else if (typeof img === 'string') { | |
| subImageUrls.push(img); | |
| } | |
| } | |
| const subImageUrls = await Promise.all( | |
| subImage.map((img) => | |
| img instanceof File ? uploadImage(img) : Promise.resolve(img as string), | |
| ), | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/myactivity/hooks/useCreateActivityForm.ts around lines
47 - 54, Replace the sequential for...of+await loop that uploads each entry in
subImage with a parallel Promise.all approach: map subImage to an array of
promises where each element returns uploadImage(img) if img is a File or returns
the img string otherwise, then await Promise.all on that array and assign the
resulting array to subImageUrls (or push the results) so uploads happen
concurrently; locate the logic in useCreateActivityForm where subImage,
uploadImage, and subImageUrls are referenced.
| }, | ||
| ); | ||
|
|
||
| revalidateTag(`activity-${id}`); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Next.js revalidateTag API documentation single argument deprecated
💡 Result:
revalidateTag(tag) with a single argument is deprecated in recent Next.js versions. The docs now require a second argument that specifies the cache revalidation behavior (a cache-life “profile”). [1]
What to use instead
Recommended (stale-while-revalidate):
import { revalidateTag } from 'next/cache'
revalidateTag('posts', 'max')This marks the tag as stale and refreshes in the background on the next visit (SWR semantics). [1][2]
If you need immediate expiration (read-your-own-writes):
- In Server Actions, use:
import { updateTag } from 'next/cache'
updateTag('posts')updateTag is Server Actions only and causes the next request to be a blocking revalidate/cache miss. [1][3]
- In Route Handlers (where
updateTagis not allowed), you can pass an explicit expire behavior via the second argument (e.g.{ expire: 0 }) per the updated API reference. [2]
Relevant docs
- Next.js error doc: “revalidateTag Single Argument Deprecated” [1]
- API reference:
revalidateTag(tag: string, profile: string | { expire?: number })[2] - Docs mismatch discussion/context (profile shown optional in some places): GitHub issue
#85281[4]
Sources: [1] [2] [3] [4]
🏁 Script executed:
cd src/app/api/editActivity && find . -name "route.ts" -type f && head -50 "[id]/route.ts"Repository: codeit-2team/GlobalNomad
Length of output: 1531
🏁 Script executed:
rg "revalidateTag" --type ts --type tsx -nRepository: codeit-2team/GlobalNomad
Length of output: 95
🏁 Script executed:
rg "revalidateTag" --type ts -nRepository: codeit-2team/GlobalNomad
Length of output: 603
revalidateTag 단일 인자 형식은 Deprecated — 두 번째 인자를 추가하세요.
공식 Next.js 문서에 따르면, revalidateTag(tag) 단일 인자 형식은 deprecated이며 TypeScript 오류가 발생합니다. 이 Route Handler에서는 권장되는 형식인 revalidateTag(tag, 'max')를 사용하세요. 'max'는 stale-while-revalidate(SWR) 의미론으로 작동하며, 다음 요청 시 백그라운드에서 갱신됩니다.
🛠️ 수정 제안
- revalidateTag(`activity-${id}`);
+ revalidateTag(`activity-${id}`, 'max');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/api/editActivity/`[id]/route.ts at line 40, The call to revalidateTag
in the Route Handler uses the deprecated single-argument form; update the
invocation of revalidateTag(`activity-${id}`) to the recommended two-argument
form revalidateTag(`activity-${id}`, 'max') so TypeScript errors are resolved
and the tag is revalidated using SWR semantics; locate the usage of
revalidateTag in route.ts and change the call accordingly.
| if (response.data?.activityId) { | ||
| revalidateTag(`activity-${response.data.activityId}`); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for review creation API response type definitions
echo "=== Looking for review-related types and API responses ==="
rg -n --type=ts "response.*activityId|activityId.*response" -B3 -A3Repository: codeit-2team/GlobalNomad
Length of output: 712
🏁 Script executed:
#!/bin/bash
# Search for revalidateTag usage to understand the API signature
echo "=== Checking revalidateTag usage patterns ==="
rg -n "revalidateTag" --type=ts -B2 -A2Repository: codeit-2team/GlobalNomad
Length of output: 2434
🏁 Script executed:
#!/bin/bash
# Look at the specific file mentioned in the review
echo "=== Examining the review route file ==="
cat -n src/app/api/reservations/[id]/reviews/route.tsRepository: codeit-2team/GlobalNomad
Length of output: 2077
🏁 Script executed:
#!/bin/bash
# Search for review API response type definitions
echo "=== Looking for review response types ==="
rg -n "interface.*Review|type.*Review" --type=ts | head -30Repository: codeit-2team/GlobalNomad
Length of output: 629
🏁 Script executed:
#!/bin/bash
# Search for the mypage/reservations/page.tsx file to understand the client pattern
echo "=== Checking client-side reservation/review code ==="
fd "reservations.*page" --type=ts --type=tsxRepository: codeit-2team/GlobalNomad
Length of output: 307
🏁 Script executed:
#!/bin/bash
# Check the ReviewResponse and CreateReviewRequest interfaces
echo "=== Examining ReviewResponse and CreateReviewRequest ==="
cat -n src/types/reservationTypes.ts | grep -A20 "interface ReviewResponse\|interface CreateReviewRequest"Repository: codeit-2team/GlobalNomad
Length of output: 549
🏁 Script executed:
#!/bin/bash
# Find mypage/reservations files
echo "=== Finding mypage/reservations files ==="
find . -path "*/mypage/reservations*" -type f | head -20Repository: codeit-2team/GlobalNomad
Length of output: 544
🏁 Script executed:
#!/bin/bash
# Search for review-related code in mypage directory
echo "=== Looking for review code in mypage ==="
rg -n "activityId" --type=ts --type=tsx src/app/\*mypage\*Repository: codeit-2team/GlobalNomad
Length of output: 154
🏁 Script executed:
#!/bin/bash
# Check Next.js version to understand revalidateTag API
echo "=== Checking package.json for Next.js version ==="
grep -A2 '"next"' package.jsonRepository: codeit-2team/GlobalNomad
Length of output: 192
🏁 Script executed:
#!/bin/bash
# Check mypage/reservations/page.tsx to understand the client pattern
echo "=== Examining mypage/reservations/page.tsx ==="
cat -n src/app/\(with-header\)/mypage/reservations/page.tsxRepository: codeit-2team/GlobalNomad
Length of output: 11281
🏁 Script executed:
#!/bin/bash
# Check ReviewModal.tsx to see how reviews are created
echo "=== Examining ReviewModal.tsx ==="
cat -n src/app/\(with-header\)/mypage/reservations/components/ReviewModal.tsx | head -100Repository: codeit-2team/GlobalNomad
Length of output: 3934
🏁 Script executed:
#!/bin/bash
# Search for revalidateTag usage patterns in Next.js 15.x context
echo "=== Checking if revalidateTag is used with second argument anywhere ==="
rg "revalidateTag.*\)" --type ts -A1 -B1Repository: codeit-2team/GlobalNomad
Length of output: 830
조건부 guard를 제거하고 revalidateTag 호출을 항상 실행하세요.
ReviewResponse 타입 정의에서 activityId는 필수 필드이므로 (activityId: number;), 요청이 성공하면 응답에 activityId가 항상 포함됩니다. if (response.data?.activityId) 조건은 불필요한 방어적 코딩입니다. 또한 revalidateTag는 현재 단일 인자 형식이 표준이며, 이미 전체 코드베이스에서 일관되게 사용되고 있습니다.
🛠️ 수정 예시
- if (response.data?.activityId) {
- revalidateTag(`activity-${response.data.activityId}`);
- }
+ revalidateTag(`activity-${response.data.activityId}`);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/api/reservations/`[id]/reviews/route.ts around lines 42 - 44, Remove
the unnecessary conditional guard and always call revalidateTag using the
required activityId from the ReviewResponse; specifically, in the route handler
replace the if-check around revalidateTag and directly invoke
revalidateTag(`activity-${response.data.activityId}`), relying on the
ReviewResponse.type (activityId: number) and the existing single-argument
revalidateTag usage.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx:
- Line 60: The enabled condition includes redundant checks (!!year && !!month)
that are always truthy since year and month are initialized, so remove them and
change the enabled expression to only depend on activityId and isOwner (e.g.,
use activityId presence and !isOwner) in the same object where enabled is
defined (referencing enabled, activityId, year, month, isOwner to locate the
line).
- Line 27: The condition userId != null is redundant because the userId prop is
typed as number; update the isOwner calculation by removing that redundant guard
so it reads a single check comparing currentUserId and userId while still
guarding currentUserId (e.g., replace the expression in the isOwner constant
with a null-check only for currentUserId and direct equality to userId); target
the isOwner declaration in BookingSection.tsx and the userId prop usage to
implement this simplification.
- Around line 32-58: The code currently awaits currentResponse before starting
prev/next requests causing serial latency; change to start all three requests
concurrently by creating promises with privateInstance.get for the current month
and the two side months (using activityId and padMonth) before awaiting; then
await them together (e.g., via Promise.allSettled), ensure you check the current
month's promise result first and if it rejected rethrow the error to preserve
previous failure behavior, and finally collect fulfilled side responses
(filtering PromiseFulfilledResult for the two side promises) and merge their
data with the currentResponse.data.
---
Duplicate comments:
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx:
- Around line 63-68: The handler handleMonthChange currently wraps setYear and
setMonth in an unnecessary setTimeout; remove the setTimeout wrapper so the
function directly calls setYear(year) and setMonth(month). Keep it as a
useCallback and ensure the callback references the existing state setters
(setYear, setMonth) so the dependency array remains appropriate (they are stable
from useState).
| const currentUserId = useUserStore((state) => | ||
| state.user ? state.user.id : null, | ||
| ); | ||
| const isOwner = currentUserId != null && userId != null && currentUserId === userId; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
userId != null 조건이 항상 true이므로 제거 가능합니다.
userId prop이 number로 선언되어 있으므로 userId != null 가드는 불필요합니다.
♻️ 수정 제안
-const isOwner = currentUserId != null && userId != null && currentUserId === userId;
+const isOwner = currentUserId != null && currentUserId === userId;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const isOwner = currentUserId != null && userId != null && currentUserId === userId; | |
| const isOwner = currentUserId != null && currentUserId === userId; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx at line
27, The condition userId != null is redundant because the userId prop is typed
as number; update the isOwner calculation by removing that redundant guard so it
reads a single check comparing currentUserId and userId while still guarding
currentUserId (e.g., replace the expression in the isOwner constant with a
null-check only for currentUserId and direct equality to userId); target the
isOwner declaration in BookingSection.tsx and the userId prop usage to implement
this simplification.
| const prevMonth = month === 1 ? 12 : month - 1; | ||
| const prevYear = month === 1 ? year - 1 : year; | ||
| const nextMonth = month === 12 ? 1 : month + 1; | ||
| const nextYear = month === 12 ? year + 1 : year; | ||
|
|
||
|
|
||
| const currentResponse = await privateInstance.get<GroupedSchedule[]>( | ||
| `/activities/${activityId}/available-schedule?year=${year}&month=${padMonth(month)}`, | ||
| ); | ||
|
|
||
|
|
||
| const sideResults = await Promise.allSettled([ | ||
| privateInstance.get<GroupedSchedule[]>( | ||
| `/activities/${activityId}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`, | ||
| ), | ||
| privateInstance.get<GroupedSchedule[]>( | ||
| `/activities/${activityId}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`, | ||
| ), | ||
| ]); | ||
|
|
||
| const sideData = sideResults | ||
| .filter( | ||
| (r): r is PromiseFulfilledResult<AxiosResponse<GroupedSchedule[]>> => r.status === 'fulfilled', | ||
| ) | ||
| .flatMap((r) => r.value.data); | ||
|
|
||
| return [...sideData, ...currentResponse.data]; |
There was a problem hiding this comment.
현재 월 요청이 이전/다음 월 요청을 블로킹하여 직렬 레이턴시가 발생합니다.
현재 흐름에서 currentResponse를 await한 뒤에야 sideResults의 두 요청이 시작됩니다. 세 요청을 Promise.allSettled로 동시에 실행하면 총 소요 시간이 T(현재) + max(T(이전), T(다음))에서 max(T(현재), T(이전), T(다음))으로 단축됩니다. 현재 월 요청 실패 시 전체 쿼리를 실패시키는 기존 동작도 그대로 유지할 수 있습니다.
⚡ 수정 제안: 세 요청 병렬화
- const currentResponse = await privateInstance.get<GroupedSchedule[]>(
- `/activities/${activityId}/available-schedule?year=${year}&month=${padMonth(month)}`,
- );
-
- const sideResults = await Promise.allSettled([
- privateInstance.get<GroupedSchedule[]>(
- `/activities/${activityId}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`,
- ),
- privateInstance.get<GroupedSchedule[]>(
- `/activities/${activityId}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`,
- ),
- ]);
+ const [currentResult, ...sideResults] = await Promise.allSettled([
+ privateInstance.get<GroupedSchedule[]>(
+ `/activities/${activityId}/available-schedule?year=${year}&month=${padMonth(month)}`,
+ ),
+ privateInstance.get<GroupedSchedule[]>(
+ `/activities/${activityId}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`,
+ ),
+ privateInstance.get<GroupedSchedule[]>(
+ `/activities/${activityId}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`,
+ ),
+ ]);
+
+ if (currentResult.status === 'rejected') throw currentResult.reason;
const sideData = sideResults
.filter(
(r): r is PromiseFulfilledResult<AxiosResponse<GroupedSchedule[]>> => r.status === 'fulfilled',
)
.flatMap((r) => r.value.data);
- return [...sideData, ...currentResponse.data];
+ return [...sideData, ...currentResult.value.data];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx around
lines 32 - 58, The code currently awaits currentResponse before starting
prev/next requests causing serial latency; change to start all three requests
concurrently by creating promises with privateInstance.get for the current month
and the two side months (using activityId and padMonth) before awaiting; then
await them together (e.g., via Promise.allSettled), ensure you check the current
month's promise result first and if it rejected rethrow the error to preserve
previous failure behavior, and finally collect fulfilled side responses
(filtering PromiseFulfilledResult for the two side promises) and merge their
data with the currentResponse.data.
|
|
||
| return [...sideData, ...currentResponse.data]; | ||
| }, | ||
| enabled: !!activityId && !!year && !!month && !isOwner, |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
!!year && !!month 조건이 항상 true이므로 제거 가능합니다.
year는 new Date().getFullYear()(예: 2026), month는 1~12 범위의 값으로 초기화되어 둘 다 항상 truthy입니다.
♻️ 수정 제안
- enabled: !!activityId && !!year && !!month && !isOwner,
+ enabled: !!activityId && !isOwner,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| enabled: !!activityId && !!year && !!month && !isOwner, | |
| enabled: !!activityId && !isOwner, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx at line
60, The enabled condition includes redundant checks (!!year && !!month) that are
always truthy since year and month are initialized, so remove them and change
the enabled expression to only depend on activityId and isOwner (e.g., use
activityId presence and !isOwner) in the same object where enabled is defined
(referencing enabled, activityId, year, month, isOwner to locate the line).
📌 변경 사항 개요
📝 상세 내용
🔗 관련 이슈
🖼️ 스크린샷(선택사항)
💡 참고 사항
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항
수정